Skip to content

fix(query-core): prevent state override when observer remount occurs with signal consumption #9580

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

joseph0926
Copy link
Contributor

@joseph0926 joseph0926 commented Aug 20, 2025

fixes: #9579
comment: #9579 (comment)

Description

This PR fixes an issue where isLoading incorrectly becomes false during an active fetch when using React StrictMode with signal destructuring in the queryFn.

Problem

This issue does not appear to be solely related to StrictMode, but rather occurs when using signal within the queryFn under StrictMode.
In fact, if you remove the signal from the provided code as shown below,

queryFn: () => {
  return new Promise((resolve) => {
    setTimeout(
      () => {
        const now = Date.now();
        console.log(`fetchCompleted index=${index}, time=${now}`);
        resolve(`request ${index}, time=${now}`);
      },
      4000,
      // { signal }
    );
  });
},

isLoading will correctly become true.
The problem arises because when the signal option is present and the fetch starts, destructuring the signal sets abortSignalConsumed = true

this.#abortSignalConsumed = true
. Due to StrictMode, during cleanup the following condition is met inside removeObserver

if (this.#abortSignalConsumed) {
  this.#retryer.cancel({ revert: true });
}

if (this.#abortSignalConsumed) {
this.#retryer.cancel({ revert: true })

This triggers an asynchronous execution of this.#retryer.cancel({ revert: true }).
As a result, the sequence becomes: immediate re-mount → new observer added → second fetch begins → meanwhile, the asynchronous catch logic from the first fetch executes, which then hits the following branch

else if (error.revert) {
  this.setState({
    ...this.#revertState,
    fetchStatus: 'idle' as const,
  })
}

} else if (error.revert) {
this.setState({
...this.#revertState,
fetchStatus: 'idle' as const,
})
// transform error into reverted state data
// if the initial fetch was cancelled, we have no data, so we have
// to get reject with a CancelledError
if (this.state.data === undefined) {
throw error
}
return this.state.data
}

This causes the state to be overwritten with the initial values, leading to the observed issue.
The reason it did not occur in the previous version is that onError existed and handled the case, but now the logic is fully asynchronous and falls into catch, which results in the problem you are seeing.

Solution

The fix adds a check to prevent state reversion when

  1. The cancellation was triggered by observer removal (isObserverRemoval flag)
  2. New observers have already been added (this.observers.length > 0)

This pattern is typical of React StrictMode's cleanup simulation where components are immediately remounted.

Changes

  • Added isObserverRemoval flag to CancelOptions and CancelledError
  • Modified removeObserver to pass this flag when cancelling with revert
  • Updated the catch block in fetch method to check for this condition before reverting state
  • Added test case to verify the fix

Testing

  • All existing tests pass
  • Added new test: should not override fetching state when revert happens after new observer subscribes
  • Manually tested with the reproduction case in StrictMode

Summary by CodeRabbit

  • Bug Fixes

    • More reliable fetch state during rapid observer changes; avoids unintended reverts while a new fetch is active.
    • Observer-removal cancellations now return cached data when available, reducing UI flicker and unnecessary idle transitions.
    • Improved cancellation semantics to better distinguish observer-removal scenarios.
  • Types

    • Type definitions extended to carry an optional flag for observer-removal-driven cancellations.
  • Tests

    • New tests for overlapping in-flight fetches, cancellation behavior, and observer churn.

Copy link

coderabbitai bot commented Aug 20, 2025

Walkthrough

Adds two tests for observer churn and overlapping fetches; tags observer-removal cancellations with isObserverRemoval; changes query fetch-error handling to return cached data when an observer-removal cancellation occurs and cached data exists; adds isObserverRemoval to CancelledError and CancelOptions. No public method signatures changed.

Changes

Cohort / File(s) Summary of Changes
Tests: observer churn and concurrent fetches
packages/query-core/src/__tests__/query.test.tsx
Added two tests validating fetchStatus and cancellation behavior when observers unsubscribe during in-flight fetches and a new observer triggers another fetch.
Query cancellation/revert logic
packages/query-core/src/query.ts
When removing an observer, call retryer.cancel with { revert: true, isObserverRemoval: true }. On handling CancelledError with revert && isObserverRemoval, if observers exist and state.data is defined return that data; otherwise rethrow. Existing revert path preserved for other cases.
Public types: cancellation metadata
packages/query-core/src/retryer.ts, packages/query-core/src/types.ts
Added optional isObserverRemoval?: boolean to CancelledError and CancelOptions and propagate it through constructor/options. No changes to public method signatures.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant O as Observer1
  participant Q as Query
  participant R as Retryer
  participant O2 as Observer2

  O->>Q: subscribe() / trigger fetch A
  Q->>R: start(fetch A, signalA)
  Note over O,Q: Observer1 unmounts (StrictMode churn)
  O-->>Q: unsubscribe()
  Q->>R: cancel({revert: true, isObserverRemoval: true})

  O2->>Q: subscribe() / trigger fetch B
  Q->>R: start(fetch B, signalB)

  R-->>Q: throw CancelledError{revert:true,isObserverRemoval:true} (for A)
  alt isObserverRemoval && observers exist
    alt state.data defined
      Q-->>O2: keep fetchStatus 'fetching' and return cached data
    else
      Q-->>O2: rethrow CancelledError
    end
  else
    Q->>Q: revert to #revertState; set fetchStatus 'idle'
  end

  R-->>Q: success for B -> Q updates state to data from B
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Assessment against linked issues

Objective Addressed Explanation
Preserve initial isLoading/isFetching semantics under StrictMode by preventing premature revert on observer churn (#9579)
Avoid introducing breaking public API changes while fixing state regression (#9579)

Possibly related PRs

Poem

A hop, a stop — observers leap and flee,
I keep the fetches warm beneath the tree.
"Cancelled?" I whisper, "only if no cache stays,"
Else data dances on through churned‑out days. 🐇🥕

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.


📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 9ed6ed1 and 87f4f45.

📒 Files selected for processing (1)
  • packages/query-core/src/__tests__/query.test.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/query-core/src/tests/query.test.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Test
  • GitHub Check: Preview
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

nx-cloud bot commented Aug 20, 2025

View your CI Pipeline Execution ↗ for commit a69008b

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 2m 26s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 26s View ↗

☁️ Nx Cloud last updated this comment at 2025-08-20 05:55:01 UTC

Copy link

pkg-pr-new bot commented Aug 20, 2025

More templates

@tanstack/angular-query-devtools-experimental

npm i https://pkg.pr.new/@tanstack/angular-query-devtools-experimental@9580

@tanstack/angular-query-experimental

npm i https://pkg.pr.new/@tanstack/angular-query-experimental@9580

@tanstack/eslint-plugin-query

npm i https://pkg.pr.new/@tanstack/eslint-plugin-query@9580

@tanstack/query-async-storage-persister

npm i https://pkg.pr.new/@tanstack/query-async-storage-persister@9580

@tanstack/query-broadcast-client-experimental

npm i https://pkg.pr.new/@tanstack/query-broadcast-client-experimental@9580

@tanstack/query-core

npm i https://pkg.pr.new/@tanstack/query-core@9580

@tanstack/query-devtools

npm i https://pkg.pr.new/@tanstack/query-devtools@9580

@tanstack/query-persist-client-core

npm i https://pkg.pr.new/@tanstack/query-persist-client-core@9580

@tanstack/query-sync-storage-persister

npm i https://pkg.pr.new/@tanstack/query-sync-storage-persister@9580

@tanstack/react-query

npm i https://pkg.pr.new/@tanstack/react-query@9580

@tanstack/react-query-devtools

npm i https://pkg.pr.new/@tanstack/react-query-devtools@9580

@tanstack/react-query-next-experimental

npm i https://pkg.pr.new/@tanstack/react-query-next-experimental@9580

@tanstack/react-query-persist-client

npm i https://pkg.pr.new/@tanstack/react-query-persist-client@9580

@tanstack/solid-query

npm i https://pkg.pr.new/@tanstack/solid-query@9580

@tanstack/solid-query-devtools

npm i https://pkg.pr.new/@tanstack/solid-query-devtools@9580

@tanstack/solid-query-persist-client

npm i https://pkg.pr.new/@tanstack/solid-query-persist-client@9580

@tanstack/svelte-query

npm i https://pkg.pr.new/@tanstack/svelte-query@9580

@tanstack/svelte-query-devtools

npm i https://pkg.pr.new/@tanstack/svelte-query-devtools@9580

@tanstack/svelte-query-persist-client

npm i https://pkg.pr.new/@tanstack/svelte-query-persist-client@9580

@tanstack/vue-query

npm i https://pkg.pr.new/@tanstack/vue-query@9580

@tanstack/vue-query-devtools

npm i https://pkg.pr.new/@tanstack/vue-query-devtools@9580

commit: a69008b

Copy link

codecov bot commented Aug 20, 2025

Codecov Report

❌ Patch coverage is 83.33333% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 59.25%. Comparing base (a1b1279) to head (a69008b).

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main    #9580       +/-   ##
===========================================
+ Coverage   45.15%   59.25%   +14.10%     
===========================================
  Files         208      137       -71     
  Lines        8323     5566     -2757     
  Branches     1886     1495      -391     
===========================================
- Hits         3758     3298      -460     
+ Misses       4118     1964     -2154     
+ Partials      447      304      -143     
Components Coverage Δ
@tanstack/angular-query-devtools-experimental ∅ <ø> (∅)
@tanstack/angular-query-experimental 87.00% <ø> (ø)
@tanstack/eslint-plugin-query ∅ <ø> (∅)
@tanstack/query-async-storage-persister 43.85% <ø> (ø)
@tanstack/query-broadcast-client-experimental 24.39% <ø> (ø)
@tanstack/query-codemods ∅ <ø> (∅)
@tanstack/query-core 97.35% <83.33%> (-0.05%) ⬇️
@tanstack/query-devtools 3.48% <ø> (ø)
@tanstack/query-persist-client-core 79.47% <ø> (ø)
@tanstack/query-sync-storage-persister 84.61% <ø> (ø)
@tanstack/query-test-utils ∅ <ø> (∅)
@tanstack/react-query 95.95% <ø> (ø)
@tanstack/react-query-devtools 10.00% <ø> (ø)
@tanstack/react-query-next-experimental ∅ <ø> (∅)
@tanstack/react-query-persist-client 100.00% <ø> (ø)
@tanstack/solid-query 78.13% <ø> (ø)
@tanstack/solid-query-devtools ∅ <ø> (∅)
@tanstack/solid-query-persist-client 100.00% <ø> (ø)
@tanstack/svelte-query 87.58% <ø> (ø)
@tanstack/svelte-query-devtools ∅ <ø> (∅)
@tanstack/svelte-query-persist-client 100.00% <ø> (ø)
@tanstack/vue-query 71.10% <ø> (ø)
@tanstack/vue-query-devtools ∅ <ø> (∅)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
packages/query-core/src/types.ts (1)

1329-1333: Add brief JSDoc for the new CancelOptions.isObserverRemoval flag

Nice, the new flag threads intent through the cancel pipeline. Since CancelOptions is part of the public types, add a short JSDoc to clarify internal usage so consumers don’t rely on it.

 export interface CancelOptions {
   revert?: boolean
   silent?: boolean
+  /**
+   * Internal: marks cancellation caused by observer removal to disambiguate revert behavior.
+   * Not intended for external usage.
+   * @internal
+   */
   isObserverRemoval?: boolean
 }
packages/query-core/src/query.ts (1)

556-573: Avoid reverting to idle when a new active observer exists; refine the guard

The new guard prevents an async revert from overriding an in-flight fetch after a quick re-subscribe. This addresses the StrictMode remount issue.

To avoid edge cases where a disabled observer re-subscribes (enabled: false) and we still skip revert (leaving fetchStatus stuck at 'fetching' without an active fetch), consider checking for an active observer rather than just a non-empty list.

-          if (error.isObserverRemoval && this.observers.length > 0) {
+          if (error.isObserverRemoval && this.isActive()) {
             if (this.state.data === undefined) {
               throw error
             }
             return this.state.data
           }

Optionally, add a short comment explaining why revert is skipped to aid future maintainers.

packages/query-core/src/__tests__/query.test.tsx (1)

1195-1239: Remove ts-expect-error by typing the mock; clarify intent

The test relies on destructuring signal to mark the abortSignal as consumed. You can avoid the ts-expect-error by typing the mock to QueryFunction and keep the test self-documenting.

-    // @ts-expect-error This field has been added for troubleshooting purposes. Disable ts error for testing.
-    const queryFn = vi.fn(async ({ signal }) => {
+    const queryFn = vi.fn<QueryFunction<string, ReturnType<typeof queryKey>>>(async ({ signal }) => {
       await sleep(50)
       return 'data'
     })

Additionally, add the missing type import at the top of the file:

// augment existing import type block from '..'
import type {
  QueryCache,
  QueryFunctionContext,
  QueryFunction, // <- add this
  QueryKey,
  QueryObserverResult,
} from '..'

This keeps the test free of suppression comments and makes the "signal consumption" intent explicit.

If you'd like, I can also contribute a complementary test that covers the edge-case where a disabled observer re-subscribes to ensure we still revert correctly.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 7c464e3 and 43ef2a8.

📒 Files selected for processing (4)
  • packages/query-core/src/__tests__/query.test.tsx (1 hunks)
  • packages/query-core/src/query.ts (2 hunks)
  • packages/query-core/src/retryer.ts (1 hunks)
  • packages/query-core/src/types.ts (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (2)
packages/query-core/src/retryer.ts (1)
packages/query-core/src/types.ts (1)
  • CancelOptions (1329-1333)
packages/query-core/src/__tests__/query.test.tsx (1)
packages/query-core/src/utils.ts (1)
  • sleep (355-359)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Test
  • GitHub Check: Preview
🔇 Additional comments (2)
packages/query-core/src/retryer.ts (1)

58-67: Propagate observer-removal context via CancelledError

Capturing isObserverRemoval on CancelledError is a clean way to alter downstream revert behavior without changing existing call sites. Looks good.

packages/query-core/src/query.ts (1)

350-355: Emit isObserverRemoval on cancel when signal was consumed

Good call to annotate the cancel originating from observer removal. This enables correct differentiation in the catch path and prevents unintended state reverts.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
packages/query-core/src/__tests__/query.test.tsx (2)

1196-1237: Great regression test; consider asserting the cancellation type and avoiding microtask timing flakiness

This test reliably reproduces the StrictMode remount race and validates the intended behavior. Two small improvements:

  • Assert the first fetch rejects with CancelledError (parity with the second test).
  • Replace the microtask flush with waitFor to avoid timing flakiness tied to async cancel scheduling.

Apply this diff:

-    await promise1.catch(() => {})
-
-    await Promise.resolve()
+    await expect(promise1).rejects.toBeInstanceOf(CancelledError)
+    await vi.waitFor(() =>
+      expect(query.state.fetchStatus).toBe('fetching'),
+    )

1199-1203: Minor: add a clarifying comment about signal destructuring

Destructuring signal intentionally consumes the AbortSignal getter to set #abortSignalConsumed = true. A short comment will make the intent obvious for future readers.

-    const queryFn = vi.fn(async ({ signal: _signal }) => {
+    const queryFn = vi.fn(async ({ signal: _signal }) => {
+      // Destructure `signal` to intentionally consume it so observer-removal uses revert-cancel path
       await sleep(50)
       return 'data'
     })
packages/query-core/src/query.ts (1)

556-573: Correctly avoid revert when cancellation stems from observer removal and observers are active

The guard if (error.isObserverRemoval && this.isActive()) is the key to preventing the premature flip to 'idle'. Behavior looks correct:

  • With active observers, skip revert. If there’s no cached data, rethrow to let the caller handle cancellation. If cached data exists, resolve with it without altering fetchStatus, preserving the in-progress state for the next fetch.
  • Otherwise, revert state and set fetchStatus: 'idle' (previous semantics).

Two notes:

  • Using isActive() (instead of just observers.length > 0) is more precise: it avoids skipping revert when only disabled observers are present and no new fetch will start. Good call.
  • Consider adding a brief comment explaining why we skip the revert here (to prevent the first fetch’s async cancellation from overriding the state after a new observer triggers a second fetch), for future maintainers.

Suggested inline comment:

-          if (error.isObserverRemoval && this.isActive()) {
+          // If cancellation was caused by observer removal and there are active observers again,
+          // do not revert to idle: a new fetch may already be in flight, and reverting would
+          // incorrectly flip isLoading/isFetching to false under StrictMode remounts.
+          if (error.isObserverRemoval && this.isActive()) {
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 43ef2a8 and 3fb30a9.

📒 Files selected for processing (2)
  • packages/query-core/src/__tests__/query.test.tsx (1 hunks)
  • packages/query-core/src/query.ts (2 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Preview
  • GitHub Check: Test
🔇 Additional comments (3)
packages/query-core/src/__tests__/query.test.tsx (1)

1239-1280: Strong coverage for “no data” path; semantics match the fix

This test exercises the case where the first fetch is cancelled with revert (due to observer removal), no cached data exists, and a new observer immediately triggers a fresh fetch. It correctly asserts:

  • The first promise rejects with CancelledError.
  • fetchStatus remains 'fetching'.
  • New data resolves as expected.

Looks good.

packages/query-core/src/query.ts (2)

347-356: Tag observer-removal cancels to disambiguate revert behavior

Passing isObserverRemoval: true alongside revert: true is the right lever to differentiate StrictMode cleanup from explicit user cancel. This enables fetch’s catch to avoid reverting state when a new observer is already active.


240-244: All flags and types verified

  • CancelOptions (packages/query-core/src/types.ts:1332) now declares isObserverRemoval?: boolean.
  • CancelledError (packages/query-core/src/retryer.ts:61–66) declares isObserverRemoval?: boolean and assigns it in the constructor.
  • The internal retryer.cancel({ revert: true }) call in packages/query-core/src/query.ts (line 352) correctly passes isObserverRemoval: true.

No further changes needed.

@joseph0926
Copy link
Contributor Author

@tanstack/solid-query-devtools:test:types

src/devtoolsPanel.tsx(31,11): error TS2552: Cannot find name 'CSSStyleValue'. Did you mean 'CSSStyleRule'?
 ELIFECYCLE  Command failed with exit code 2.
ERROR: "test:types:ts50" exited with 2.
 ELIFECYCLE  Command failed with exit code 1.

It seems that an error unrelated to this PR is occurring at the CI stage.

@TkDodo
Copy link
Collaborator

TkDodo commented Aug 20, 2025

Not sure about this fix. I'll have to think a bit about it but I'm on vacation now.

@joseph0926 joseph0926 changed the title fix(query-core): prevent state override when observer remount occurs … fix(query-core): prevent state override when observer remount occurs with signal consumption Aug 25, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

v5.85.2 introduced a breaking change while using StrictMode
2 participants